authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 10:51:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 10:51:25-07:00
log1e9c36023c898bfcab6dce862e105441b85ec22e
tree151f7f871051b5a232759be980759ddadbc76ebd
parent9962e6986db68cd5537d23153e7898d6b3696b85
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): adopt Expo Modules 2.0 for function-only native members (#12051)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 102 insertions(+), 99 deletions(-)

apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift+10-6
......@@ -36,14 +36,18 @@ enum T3ComposerClipboard {
3636 }
3737}
3838
39// Expo Modules 2.0: the clipboard function is a @JS member; the view stays in
40// definition() until the 2.0 view API lands.
41@ExpoModule("T3ComposerEditor")
3942public class T3ComposerEditorModule: Module {
40 public func definition() -> ModuleDefinition {
41 Name("T3ComposerEditor")
42
43 AsyncFunction("writeContextClipboard") { (text: String, fragment: String) in
44 T3ComposerClipboard.write(text: text, fragment: fragment)
45 }.runOnQueue(.main)
43 // UIPasteboard is main-thread only, so the member is main-actor isolated.
44 @JS
45 @MainActor
46 func writeContextClipboard(text: String, fragment: String) async {
47 T3ComposerClipboard.write(text: text, fragment: fragment)
48 }
4649
50 public func definition() -> ModuleDefinition {
4751 View(T3ComposerEditorView.self) {
4852 Prop("controlledDocumentJson") { (view: T3ComposerEditorView, documentJson: String) in
4953 view.setControlledDocumentJson(documentJson)
apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift+92-93
......@@ -2,14 +2,15 @@ import ExpoModulesCore
22import Security
33import UIKit
44
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")
58public final class T3NativeControlsModule: Module {
69 private let presentationSources = T3PresentationSources()
710 private var videoPresentation: T3NativeVideoPresentation?
811 private var filePresentation: T3NativeFilePresentation?
912
1013 public func definition() -> ModuleDefinition {
11 Name("T3NativeControls")
12
1314 AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
1415 try self.presentVideo(
1516 url: url,
......@@ -20,19 +21,11 @@ public final class T3NativeControlsModule: Module {
2021 )
2122 }.runOnQueue(.main)
2223
23 AsyncFunction("dismissVideo") { (identifier: String) in
24 self.dismissVideo(identifier: identifier)
25 }.runOnQueue(.main)
26
2724 AsyncFunction("presentFile") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
2825 try self.presentFile(url: url, title: title, sourceIdentifier: sourceIdentifier,
2926 identifier: identifier, promise: promise)
3027 }.runOnQueue(.main)
3128
32 AsyncFunction("dismissFile") { (identifier: String) in
33 self.dismissFile(identifier: identifier)
34 }.runOnQueue(.main)
35
3629 OnDestroy {
3730 let presentation = self.videoPresentation
3831 let file = self.filePresentation
......@@ -60,103 +53,109 @@ public final class T3NativeControlsModule: Module {
6053 AsyncFunction("shareFileFromSource") { (url: URL, title: String, identifier: String, promise: Promise) in
6154 try self.shareFile(url: url, title: title, sourceIdentifier: identifier, promise: promise)
6255 }.runOnQueue(.main)
56 }
6357
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 }
7471
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)")
8081 }
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()
8784 }
88 return arguments[flagIndex + 1]
8985 }
86 }
9087
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"
102103 }
104 let bounds = windowScene.screen.coordinateSpace.bounds
105 return bounds.width > bounds.height ? "landscape" : "portrait"
106 }
103107
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
113115 }
116 return arguments[flagIndex + 1]
117 }
114118
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 }
147123
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
152130 }
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 }
153140
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)
157150 }
158151 }
159152
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
160159 private func presentVideo(url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) throws {
161160 let isPlayableURL = url.isFileURL
162161 ? FileManager.default.isReadableFile(atPath: url.path)
......@@ -179,7 +178,7 @@ public final class T3NativeControlsModule: Module {
179178 presentation.present(from: presenter, sources: presentationSources, sourceIdentifier: sourceIdentifier)
180179 }
181180
182 private func dismissVideo(identifier: String) {
181 private func dismissVideoPresentation(identifier: String) {
183182 if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
184183 }
185184
......@@ -197,7 +196,7 @@ public final class T3NativeControlsModule: Module {
197196 file.present(url: url, title: title, from: presenter)
198197 }
199198
200 private func dismissFile(identifier: String) {
199 private func dismissFilePresentation(identifier: String) {
201200 if filePresentation?.identifier == identifier { filePresentation?.dismiss() }
202201 }
203202