1import AVFoundation
2import ExpoModulesCore
3import Photos
4import Security
5import UIKit
6import UniformTypeIdentifiers
7
8@ExpoModule("T3NativeControls")
9public final class T3NativeControlsModule: Module {
10 private let presentationSources = T3PresentationSources()
11 private var videoPresentation: T3NativeVideoPresentation?
12 private var filePresentation: T3NativeFilePresentation?
13
14 public func definition() -> ModuleDefinition {
15 Events("videoPlaybackChange")
16 Constants {
17 if #available(iOS 26.0, *) {
18 return [
19 "supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil,
20 ]
21 }
22 return ["supportsWorkspaceColumns": false]
23 }
24 View(T3SegmentedControlView.self) {
25 ViewName("SegmentedControl")
26 Events("onSelectionChange")
27 Prop("labels") { (view: T3SegmentedControlView, value: [String]) in view.labels = value }
28 Prop("selectedIndex") { (view: T3SegmentedControlView, value: Int) in view.selectedIndex = value }
29 Prop("trackColor") { (view: T3SegmentedControlView, value: UIColor) in view.trackColor = value }
30 Prop("selectedColor") { (view: T3SegmentedControlView, value: UIColor) in view.selectedColor = value }
31 Prop("textColor") { (view: T3SegmentedControlView, value: UIColor) in view.textColor = value }
32 Prop("mutedColor") { (view: T3SegmentedControlView, value: UIColor) in view.mutedColor = value }
33 Prop("appearance") { (view: T3SegmentedControlView, value: String) in view.appearance = value }
34 }
35
36 View(T3SidebarTabBarView.self) {
37 ViewName("SidebarTabBar")
38 Events("onSelectionChange", "onCompose")
39 Prop("tabs") { (view: T3SidebarTabBarView, value: [String]) in view.tabs = value }
40 Prop("selectedTab") { (view: T3SidebarTabBarView, value: String) in view.selectedTab = value }
41 Prop("selectedColor") { (view: T3SidebarTabBarView, value: UIColor) in view.selectedColor = value }
42 Prop("showsCompose") { (view: T3SidebarTabBarView, value: Bool) in view.showsCompose = value }
43 Prop("compact") { (view: T3SidebarTabBarView, value: Bool) in view.compact = value }
44 Prop("appearance") { (view: T3SidebarTabBarView, value: String) in view.appearance = value }
45 }
46
47 View(T3LayoutMetricsView.self) {
48 ViewName("LayoutMetrics")
49 Events("onMetricsChange")
50 }
51
52 View(T3ScrollEdgeBleedView.self) {
53 ViewName("ScrollEdgeBleed")
54 }
55
56 AsyncFunction("generateDpopKey") { T3DpopKeys.generate() }
57
58 AsyncFunction("dpopKeyMatches") { (privateKey: Data, publicKey: Data) in
59 try T3DpopKeys.matches(privateKey: privateKey, publicKey: publicKey)
60 }
61
62 AsyncFunction("signDpop") { (privateKey: Data, signingInput: Data) in
63 try T3DpopKeys.sign(privateKey: privateKey, signingInput: signingInput)
64 }
65
66 AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
67 try self.presentVideo(
68 url: url,
69 title: title,
70 sourceIdentifier: sourceIdentifier,
71 identifier: identifier,
72 promise: promise
73 )
74 }.runOnQueue(.main)
75
76 AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
77 try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,
78 identifier: identifier, promise: promise)
79 }.runOnQueue(.main)
80
81 // A nil URL is still resolving in JS and arrives later through updateFile.
82 AsyncFunction("presentFiles") { (urls: [URL?], titles: [String], selectedIndex: Int, sourceIdentifiers: [String], identifier: String, promise: Promise) in
83 guard !urls.isEmpty, urls.count == titles.count, urls.count == sourceIdentifiers.count,
84 urls.indices.contains(selectedIndex)
85 else {
86 throw URLError(.badURL)
87 }
88 let file = try self.makeFilePresentation(sourceIdentifiers: sourceIdentifiers, identifier: identifier,
89 promise: promise)
90 guard let presenter = self.appContext?.utilities?.currentViewController() else {
91 file.dismiss()
92 return
93 }
94 file.present(urls: urls, titles: titles, selectedIndex: selectedIndex, from: presenter)
95 }.runOnQueue(.main)
96
97 // A nil URL marks the item as one that could not be resolved.
98 AsyncFunction("updateFile") { (identifier: String, index: Int, url: URL?) in
99 if self.filePresentation?.identifier == identifier { self.filePresentation?.update(index: index, url: url) }
100 }.runOnQueue(.main)
101
102 OnDestroy {
103 let presentation = self.videoPresentation
104 let file = self.filePresentation
105 DispatchQueue.main.async {
106 presentation?.dismiss()
107 file?.dismiss()
108 }
109 }
110
111 View(T3PresentationSourceView.self) {
112 ViewName("PresentationSource")
113 Prop("identifier") { (view: T3PresentationSourceView, identifier: String) in
114 view.sources = self.presentationSources
115 view.identifier = identifier
116 }
117 }
118
119 View(T3FrostedCutoutView.self) {
120 ViewName("FrostedCutout")
121 Prop("cutoutTop") { (view: T3FrostedCutoutView, value: Double) in view.cutoutTop = CGFloat(value) }
122 Prop("cutoutWidth") { (view: T3FrostedCutoutView, value: Double) in view.cutoutWidth = CGFloat(value) }
123 Prop("cutoutHeight") { (view: T3FrostedCutoutView, value: Double) in view.cutoutHeight = CGFloat(value) }
124 Prop("cutoutRadius") { (view: T3FrostedCutoutView, value: Double) in view.cutoutRadius = CGFloat(value) }
125 Prop("appearance") { (view: T3FrostedCutoutView, value: String) in view.appearance = value }
126 }
127
128 View(T3ContextSheetSizeView.self) {
129 ViewName("ContextSheetSize")
130 Prop("contentHeight") { (view: T3ContextSheetSizeView, height: Double) in
131 view.contentHeight = CGFloat(height)
132 }
133 }
134
135 AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in
136 try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise)
137 }.runOnQueue(.main)
138
139 // Photos names the asset itself, so iOS ignores the Android display name.
140 AsyncFunction("saveImageToLibrary") { (url: URL, mimeType: String, name: String, promise: Promise) in
141 Self.saveImageToLibrary(url: url, mimeType: mimeType, name: name, promise: promise)
142 }
143 }
144
145 // UIKit presenters are main-actor isolated rather than run on the JS thread
146 // the macro would otherwise pick.
147 @JS
148 @MainActor
149 func dismissVideo(identifier: String) async {
150 dismissVideoPresentation(identifier: identifier)
151 }
152
153 // expo-video activates the session for inline playback but never deactivates
154 // it. Any other category or mode means another feature (recording) owns it.
155 @JS
156 @MainActor
157 func releaseVideoAudioSession() async {
158 // The fullscreen presentation releases the session in its own teardown.
159 guard videoPresentation == nil else { return }
160 let session = AVAudioSession.sharedInstance()
161 guard session.category == .playback, session.mode == .moviePlayback else { return }
162 try? session.setActive(false, options: [.notifyOthersOnDeactivation])
163 }
164
165 @JS
166 @MainActor
167 func dismissFile(identifier: String) async {
168 dismissFilePresentation(identifier: identifier)
169 }
170
171 // Rotates the interface without Simulator menu UI scripting, which CI
172 // runners cannot perform (osascript is denied Accessibility access there).
173 @JS
174 @MainActor
175 func applyShowcaseOrientation(orientation: String) async {
176 let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait
177 for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes {
178 windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in
179 NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)")
180 }
181 for window in windowScene.windows {
182 window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
183 }
184 }
185 }
186
187 // Geometry requests can fail before foreground activation; callers retry until bounds match.
188 // Use screen bounds because iPadOS floating-window orientation can differ from the captured framebuffer.
189 @JS
190 @MainActor
191 func getInterfaceOrientation() async -> String {
192 guard
193 let windowScene = UIApplication.shared.connectedScenes
194 .compactMap({ $0 as? UIWindowScene })
195 .first
196 else {
197 return "unknown"
198 }
199 let bounds = windowScene.screen.coordinateSpace.bounds
200 return bounds.width > bounds.height ? "landscape" : "portrait"
201 }
202
203 @JS
204 @MainActor
205 func isProtectedDataAvailable() async -> Bool {
206 UIApplication.shared.isProtectedDataAvailable
207 }
208
209 private func launchArgument(_ flag: String) -> String? {
210 let arguments = ProcessInfo.processInfo.arguments
211 guard
212 let flagIndex = arguments.firstIndex(of: flag),
213 arguments.indices.contains(flagIndex + 1)
214 else {
215 return nil
216 }
217 return arguments[flagIndex + 1]
218 }
219
220 @JS
221 func getShowcasePairingUrl() -> String? {
222 launchArgument("--showcasePairingUrl")
223 }
224
225 @JS
226 func getShowcaseScene() -> String? {
227 let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene"
228 if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8)
229 .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty {
230 return storedScene
231 }
232 return launchArgument("--showcaseScene")
233 }
234
235 // The palette is fixed for the whole capture, so it only ever arrives as a
236 // launch argument — unlike the scene, which the runner rewrites in place.
237 @JS
238 func getShowcaseTheme() -> String? {
239 launchArgument("--showcaseTheme")
240 }
241
242 @JS
243 func getShowcaseOrientation() -> String? {
244 launchArgument("--showcaseOrientation")
245 }
246
247 @JS
248 func prepareShowcaseCapture() {
249 for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] {
250 SecItemDelete([kSecClass as String: itemClass] as CFDictionary)
251 }
252 }
253
254 @JS(.concurrent)
255 func markShowcaseReady(scene: String) async {
256 let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene"
257 try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)
258 }
259
260 private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws {
261 let isPlayableURL = url.isFileURL
262 ? FileManager.default.isReadableFile(atPath: url.path)
263 : (["https", "http"].contains(url.scheme?.lowercased() ?? "") && url.host != nil)
264 guard videoPresentation == nil, filePresentation == nil,
265 let presenter = appContext?.utilities?.currentViewController(),
266 isPlayableURL
267 else {
268 throw NSError(
269 domain: "T3NativeVideo",
270 code: 2,
271 userInfo: [NSLocalizedDescriptionKey: "The video preview is no longer available."]
272 )
273 }
274 let presentation = T3NativeVideoPresentation(
275 identifier: identifier, url: url, title: title,
276 onPlaybackChange: { [weak self] isPlaying in
277 self?.sendEvent("videoPlaybackChange", ["identifier": identifier, "isPlaying": isPlaying])
278 },
279 completion: { [weak self] error in
280 self?.videoPresentation = nil
281 if let error { promise.reject(error) } else { promise.resolve(nil) }
282 }
283 )
284 videoPresentation = presentation
285 presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier)
286 }
287
288 private func dismissVideoPresentation(identifier: String) {
289 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
290 }
291
292 private func makeFilePresentation(sourceIdentifiers: [String], identifier: String,
293 promise: Promise) throws -> T3NativeFilePresentation {
294 guard filePresentation == nil, videoPresentation == nil,
295 appContext?.utilities?.currentViewController() != nil
296 else { throw URLError(.cannotLoadFromNetwork) }
297 let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources,
298 sourceIdentifiers: sourceIdentifiers) { [weak self] error in
299 self?.filePresentation = nil
300 if let error { promise.reject(error) } else { promise.resolve(nil) }
301 }
302 filePresentation = file
303 return file
304 }
305
306 private func presentFile(url: URL, title: String, sourceIdentifier: String,
307 identifier: String, promise: Promise) throws {
308 let file = try makeFilePresentation(sourceIdentifiers: [sourceIdentifier], identifier: identifier, promise: promise)
309 guard let presenter = appContext?.utilities?.currentViewController() else { file.dismiss(); return }
310 file.present(url: url, title: title, from: presenter)
311 }
312
313 private func dismissFilePresentation(identifier: String) {
314 if filePresentation?.identifier == identifier { filePresentation?.dismiss() }
315 }
316
317 private static func saveImageToLibrary(url: URL, mimeType: String, name: String, promise: Promise) {
318 PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
319 guard status == .authorized || status == .limited else {
320 promise.reject(NSError(
321 domain: "T3NativePhotos",
322 code: 1,
323 userInfo: [NSLocalizedDescriptionKey: "Allow T3 Code to add photos in Settings."]
324 ))
325 return
326 }
327 PHPhotoLibrary.shared().performChanges({
328 let options = PHAssetResourceCreationOptions()
329 options.uniformTypeIdentifier = UTType(mimeType: mimeType)?.identifier
330 options.originalFilename = name
331 PHAssetCreationRequest.forAsset().addResource(with: .photo, fileURL: url, options: options)
332 }, completionHandler: { saved, error in
333 if saved {
334 promise.resolve(nil)
335 } else {
336 promise.reject(error ?? NSError(
337 domain: "T3NativePhotos",
338 code: 2,
339 userInfo: [NSLocalizedDescriptionKey: "Photos could not save this image."]
340 ))
341 }
342 })
343 }
344 }
345
346 private func shareFile(url: URL, title: String, sourceIdentifier: String, promise: Promise) throws {
347 guard let presenter = appContext?.utilities?.currentViewController() else {
348 throw NSError(
349 domain: "T3NativePresentation",
350 code: 2,
351 userInfo: [NSLocalizedDescriptionKey: "The presenting screen is no longer open."]
352 )
353 }
354 try presentFileShare(
355 url: url,
356 title: title,
357 source: presentationSources.view(for: sourceIdentifier),
358 presenter: presenter,
359 promise: promise
360 )
361 }
362}